1、是什么
条件变量是并发编程中的一种同步机制。条件变量使得线程能够阻塞到等待某个条件发生后,再继续执行。条件变量能够实现强大并且高效的同步机制,但是要用好条件变量,也需要我们做出不少努力。
2、为什么需要条件变量
设想一下这样子的场景:在生产者消费者模型中,我们希望在生产者制造出 100 个产品后,庆祝一下。
如果我们直接用 mutex 互斥锁来实现的话,那么我们需要在某个线程上不断地轮询:现在是不是做出 100 个产品了?伪代码如下:
1 | // Thread Producer |
相当于我们要进行大量无效的询问,才能知道条件已经满足,并且每次询问都是需要加锁的,这无疑是一种资源的浪费。
而条件变量则高效地解决了这个问题。使用条件变量的情况下,我们可以直接等待某个条件的发生,而不需要主动轮询。有了条件变量,上述伪代码就可以很方便地改写成:
1 | // Thread Producer |
现在相当于是在条件满足的时候,由生产者通知 Thread A,而不是让 Thread A 傻傻地去不断轮询,变得高效了很多。
3、如何正确使用条件变量
来看一个简单的栗子:
1 |
|
3.1、创建和销毁条件变量
首先,条件变量在使用前必须初始化,pthread_cond_init 和 pthread_cond_destroy 方法可以用来动态创建和销毁条件变量。
同时,条件变量和互斥锁一样,也有静态创建方式,静态方式使用 PTHREAD_COND_INITIALIZER
常量,如下:
1 | pthread_cond_t condition = PTHREAD_COND_INITIALIZER; |
此外,因为条件变量必须配合互斥锁使用,所以也要创建一个互斥锁。
3.2、与互斥锁配合使用
为了防止发生竞争条件,条件变量必须与互斥锁搭配使用。pthread_cond_wait 函数的调用 和 临界区 都需要受到互斥锁的保护。
3.3、等待条件的发生
当条件不满足时,使用 pthread_cond_wait
或者 pthread_cond_timedwait
函数,来让线程进入休眠。当函数正常返回时,返回值为 0。
这两个函数的区别在于,pthread_cond_timedwait
函数提供了超时返回的能力,我们可以设定一个超时时间,来避免永久的等待。当到达超时时间后,条件变量仍未满足的话,函数会返回 ETIMEOUT
。其中 abstime
以绝对时间的形式出现,0 表示格林尼治时间1970年1月1日0时0分0秒,这里常常有人误解为相对时间。
这两个 wait 函数的调用,都要在获取 mutex 锁后进行。
看到这里可能有的人会觉得疑惑:如果在 wait 之前锁住了 mutex,那其他线程在试图进入临界区时(上文 value = 1
的那行代码),不就永远获取不到 mutex 了吗?
这确实是让许多初学者觉得困惑的地方。其实函数 pthread_cond_wait 会在线程即将休眠之前,释放 mutex。因此,在线程休眠之后,其他线程就能正常锁住 mutex 了。
而后,等到其他线程触发了条件,并且 unlock 了 mutex 之后,休眠的线程在 wait 函数中会再次锁住 mutex,然后继续执行代码。
1 | pthread_mutex_lock(&mutex); |
3.4、条件触发
其他线程可以在条件满足后,通过调用 pthread_cond_signal
或者 pthread_cond_broadcast
来触发条件变量。
1 | void triggerCondition() |
pthread_cond_signal
函数可以唤醒一个处于等待中的线程,当有多个线程等待时,它会自动根据线程的优先级选择一个线程唤醒。但是某些特殊情况下,该函数可能会唤醒不止一个线程。
pthread_cond_broadcast
函数则是用“广播”的方式唤醒所有等待中的线程。例如读写锁的实现中,在写入完毕后,可以用它来唤醒所有等待中的读取操作。
值得注意的是,无论是 pthread_cond_signal
还是 pthread_cond_broadcast
都不保证唤醒的正确性。也就是说,休眠中的线程有可能在被唤醒后,发现条件依旧不满足。这是由于在函数的实现中,为了追求高性能,而放弃了一定的准确性。这通常被称为“虚假唤醒”。
此外,pthread_cond_signal
和 pthread_cond_broadcast
函数都不需要在 mutex 锁中调用。
4、注意
尽管条件变量的使用是较为简单的,但是其中也有不少的“坑”需要大家注意。下面介绍几个比较值得注意的问题。
4.1、要考虑解锁和唤醒的顺序
由于 pthread_cond_signal
和 pthread_cond_broadcast
函数的调用都不需要加锁,所以它们放到 pthread_mutex_unlock
之前或者之后执行都是可以的。但在实际使用中,需要根据具体情况考虑它们的顺序,来使得程序高效运行。
当 signal 操作发生在 unlock 之前时,其他等待的线程被唤醒,但 mutex 锁可能仍然被 signal 的线程持有着,导致被唤醒的线程无法获取到 mutex 锁,从而再次进入休眠。通常情况下,这种调用顺序就会对代码的执行效率产生不良的影响。但是在 Java 下,必须采用这种顺序进行调用,否则会发生异常。
4.2、要使用 while 而不是 if,避免虚假唤醒
细心观察可以发现,我们在等待的线程中,使用的是 while (条件不成立)
的方式来调用 wait 函数,而不是使用 if
语句。
这是由于 wait 函数被唤醒时,存在虚假唤醒等情况,导致唤醒后发现,条件依旧不成立。因此需要使用 while
语句来循环地进行等待,直到条件成立为止。
4.3、timewait 是 absolute time
pthread_cond_timedwait
函数的 abstime
指的是超时的绝对时间,而不是相对现在的时间间隔。这点经常会有人误会。
4.4、pthread_cond_timedwait 不一定会准时返回
如果 pthread_cond_timedwait
超时到了,但是这个时候 mutex 锁被其他线程持有,导致本线程不能锁定 mutex,无法进入临界区,那么 pthread_cond_timedwait
就无法立即返回。
5、NSCondition
NSCondition 是 Objective-C 中对条件变量的封装,它的底层也是基于上文所述的 POSIX 的条件变量。用法也和上文的结构相似。
它的独特之处在于,它同时封装了一个互斥锁和一个条件变量,所有的加锁和条件的操作都可以直接通过 NSCondition
对象完成。
官方示例如下:
等待条件:
1 | [cocoaCondition lock]; |
发送信号:
1 | [cocoaCondition lock]; |
6、参考文献
- https://linux.die.net/man/3/pthread_cond_wait
- https://linux.die.net/man/3/pthread_cond_signal
- https://www.ibm.com/support/knowledgecenter/en/ssw_aix_71/com.ibm.aix.genprogc/condition_variables.htm
- https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Multithreading/ThreadSafety/ThreadSafety.html
- https://bestswifter.com/ios-lock/
- https://blog.zorro.im/posts/ios-muti-threading-synchronization.html
- https://stackoverflow.com/questions/16522858/understanding-of-pthread-cond-wait-and-pthread-cond-signal
- https://blog.csdn.net/gettogetto/article/details/53872929